Skip to content

feat(core): Add bindScopeToEmitter to bind a scope to an event emitter#21594

Open
mydea wants to merge 8 commits into
developfrom
feat/bind-scope-to-emitter
Open

feat(core): Add bindScopeToEmitter to bind a scope to an event emitter#21594
mydea wants to merge 8 commits into
developfrom
feat/bind-scope-to-emitter

Conversation

@mydea

@mydea mydea commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

Adds a new bindScopeToEmitter(emitter, scope?) API to @sentry/core. It captures a scope (defaulting to the current scope) and patches the emitter's listener-registration methods so that any listener added afterwards runs with that scope — and therefore the active span — active, even when it fires later in a different async context.

This is useful when instrumenting APIs that hand back an event emitter (e.g. a streamed database query) whose 'data' / 'error' / 'end' listeners would otherwise lose the trace context. It mirrors the event-emitter behavior of OpenTelemetry's ContextManager.bind, scoped to emitters only (the function-binding path of OTel's bind is intentionally omitted — at any such call site you already hold the callback and can wrap it with withScope directly).

Behavior

  • Works with both Node.js EventEmitters (on, addListener, once, prependListener, prependOnceListener) and DOM EventTargets (addEventListener).
  • Removal methods (removeListener / off / removeEventListener) are patched so removals via the original listener reference find the wrapped listener; the addEventListener options argument is forwarded; non-function (EventListener-object) listeners pass through untouched.
  • Objects exposing none of these methods are returned untouched (browser-safe — no node:events import).
  • The isolation scope is intentionally not captured — it rides along with the active async context. Only the current scope (with its active span) is bound.

Surface

  • Exported from all SDKs (@sentry/core + node / node-core / browser, and the platform/framework re-exporters).

Note: I opted to not include this in CDN bundles right now, as this is really not that critical to browser stuff. We can always add it later if it seems necessary.

Tests

  • Unit (@sentry/core): 19 cases incl. explicit-scope arg and the DOM EventTarget / addEventListener path.
  • Node integration (public-api/bindScopeToEmitter): a listener firing in a fresh async context nests under the bound parent, with an unbound control on a separate trace.
  • Browser integration (tracing/bindScopeToEmitter): a real EventTarget + dispatchEvent, verified on the ESM build

The mysql instrumentation rewiring that consumes this API is kept on a separate branch and will follow once this lands.

🤖 Generated with Claude Code

Comment thread packages/core/src/tracing/bindScopeToEmitter.ts
@mydea mydea self-assigned this Jun 17, 2026
@mydea mydea marked this pull request as ready for review June 17, 2026 07:57
@mydea mydea requested review from a team as code owners June 17, 2026 07:57
@mydea mydea requested review from JPeer264, Lms24, andreiborza, chargome, logaretm and s1gr1d and removed request for a team June 17, 2026 07:57
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 27.45 kB - -
@sentry/browser - with treeshaking flags 25.88 kB - -
@sentry/browser (incl. Tracing) 45.91 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 48.16 kB - -
@sentry/browser (incl. Tracing, Profiling) 50.69 kB - -
@sentry/browser (incl. Tracing, Replay) 85.1 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 74.71 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 89.8 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 102.47 kB - -
@sentry/browser (incl. Feedback) 44.62 kB - -
@sentry/browser (incl. sendFeedback) 32.25 kB - -
@sentry/browser (incl. FeedbackAsync) 37.38 kB - -
@sentry/browser (incl. Metrics) 28.52 kB - -
@sentry/browser (incl. Logs) 28.76 kB - -
@sentry/browser (incl. Metrics & Logs) 29.45 kB - -
@sentry/react 29.25 kB - -
@sentry/react (incl. Tracing) 48.21 kB - -
@sentry/vue 32.58 kB - -
@sentry/vue (incl. Tracing) 47.78 kB - -
@sentry/svelte 27.48 kB - -
CDN Bundle 29.86 kB - -
CDN Bundle (incl. Tracing) 48.32 kB - -
CDN Bundle (incl. Logs, Metrics) 31.4 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 49.62 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 70.71 kB - -
CDN Bundle (incl. Tracing, Replay) 85.64 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 86.9 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 91.49 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.73 kB - -
CDN Bundle - uncompressed 88.8 kB - -
CDN Bundle (incl. Tracing) - uncompressed 146.13 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 93.5 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 150.1 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 218.33 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 264.99 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 268.95 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 278.69 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 282.64 kB - -
@sentry/nextjs (client) 50.61 kB - -
@sentry/sveltekit (client) 46.3 kB - -
@sentry/core/server 76.7 kB +0.65% +490 B 🔺
@sentry/core/browser 63.81 kB +0.75% +471 B 🔺
@sentry/node-core 61.9 kB -0.01% -1 B 🔽
@sentry/node 124.69 kB - -
@sentry/node/import (ESM hook with diagnostics-channel injection) 70.05 kB - -
@sentry/node/light 50.98 kB - -
@sentry/node - without tracing 74.28 kB - -
@sentry/aws-serverless 85.37 kB - -
@sentry/cloudflare (withSentry) - minified 174.55 kB - -
@sentry/cloudflare (withSentry) 436.86 kB - -

View base workflow run

Comment thread packages/core/src/tracing/bindScopeToEmitter.ts
Comment thread packages/core/src/tracing/bindScopeToEmitter.ts Outdated
Comment thread packages/core/src/tracing/bindScopeToEmitter.ts
@mydea mydea force-pushed the feat/bind-scope-to-emitter branch from 60891cf to 03ee2c5 Compare June 18, 2026 08:44

@JPeer264 JPeer264 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: It almost looks like an 1:1 copy from the following just that it lives in core instead of opentelemetry. Is this intended? If yes, maybe it would make sense to consolidate those two functions.

https://github.com/getsentry/sentry-javascript/blob/feat/bind-scope-to-emitter/packages/opentelemetry/src/asyncLocalStorageContextManager.ts#L115

};
}

function patchRemoveListener(ee: EventEmitterLike, original: BoundListener): BoundListener {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: In patchRemoveAllListeners we are removing the event from the map. In this function we keep the map as is, is this difference intended?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comments to explain this, here is the AI summary of why this is expected:

es, that difference is intentional and load-bearing — it's not an oversight.

The map is Map<event, WeakMap<userListener, boundWrapper>> (outer keyed by event-name string, inner keyed weakly by the user's listener).

Why patchRemoveListener must keep the entry: the whole design reuses one stable wrapper per listener (patchAddListener, lines 125–131), and Node's EventEmitter allows and counts
duplicate registrations. So a single user listener can be registered multiple times, all sharing the same wrapper B:

ee.on('data', fn); // registers wrapper B (map[data][fn] = B)
ee.on('data', fn); // registers B again — Node now has 2 copies of B
ee.removeListener('data', fn); // removes ONE copy of B

If removeListener deleted map[data][fn] here, the second still-registered copy of B would become orphaned: the next removeListener('data', fn) would no longer find B in the map, fall
through to original.apply(this, args) with the raw fn (line 149–150), and fail to match/remove the wrapper. So the mapping has to survive a single removal because other registrations of
the same wrapper may still be live.

Why patchRemoveAllListeners can delete it: removeAllListeners(event) tears down every listener for that event in one shot, so no registration referencing those wrappers remains. The map
entry is then unambiguously stale, and deleting it is safe — and a genuine cleanup, because the outer map is a plain Map keyed by event-name strings, which would otherwise accumulate
those keys forever. (The no-arg case resets the whole map, lines 162–164.)

Why leaving stale inner entries after removeListener isn't a leak: the inner structure is a WeakMap keyed by the user's listener, so once the user drops their reference to fn, the entry
is GC'd on its own. There's nothing to manually clean up there, and reusing the same wrapper if fn is re-added later is actually desirable (it preserves DOM EventTarget's (type,
callback, capture) dedup, per the comment on lines 6–14).

So: keep-on-single-remove (correctness, due to possible duplicates) vs. delete-on-remove-all (safe cleanup of the strong outer map) is the right asymmetry.

}

function createPatchMap(ee: EventEmitterLike): ListenerPatchMap {
const map = Object.create(null) as ListenerPatchMap;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Any reason why not a Map is used? Then we wouldn't need to use type assertions

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, this is nicer!

@mydea

mydea commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

q: It almost looks like an 1:1 copy from the following just that it lives in core instead of opentelemetry. Is this intended? If yes, maybe it would make sense to consolidate those two functions.

https://github.com/getsentry/sentry-javascript/blob/feat/bind-scope-to-emitter/packages/opentelemetry/src/asyncLocalStorageContextManager.ts#L115

Yeah, it is more or less a port of this! we can possibly unify this, but I'd look at this in a follow up, because not 100% sure how this interops, could be a circular dependency - withScope etc. depend on the context manager, so using this in there may be... weird? but not 100% sure.

mydea and others added 7 commits June 19, 2026 09:54
Adds a new `bindScopeToEmitter(emitter, scope?)` API to core. It captures a
scope (defaulting to the current scope) and patches the emitter's listener
registration methods so that any listener added afterwards runs with that scope
— and therefore the active span — active, even when it fires later in a
different async context.

This is useful when instrumenting APIs that hand back an event emitter (e.g. a
streamed database query) whose `'data'`/`'error'`/`'end'` listeners would
otherwise lose the trace context. It mirrors the event-emitter behavior of
OpenTelemetry's `ContextManager.bind`, scoped to emitters only.

Works with both Node.js `EventEmitter`s (`on`, `addListener`, ...) and DOM
`EventTarget`s (`addEventListener`); the isolation scope is intentionally not
captured as it is carried along by the active async context.

Exported from all SDKs and covered by unit, node-integration and
browser-integration tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the API on the npm packages only; CDN bundle size should not grow for a
Node-oriented helper. Skip the browser integration test in bundle mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…copeToEmitter

A listener can be registered for the same event more than once; each is an
independent registration in Node's EventEmitter. Storing a single wrapper per
(event, listener) meant later registrations overwrote earlier ones, so only the
most recent wrapper could be removed via the original reference and earlier ones
were orphaned. Keep a stack of wrappers and remove one per removeListener call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Minting a fresh wrapper on every registration broke DOM EventTarget semantics:
addEventListener dedupes by (type, callback, capture), so distinct wrapper refs
defeated that idempotency (the listener fired once per call) and capture-aware
removal could drop the wrong wrapper. Reuse a single stable wrapper per listener
and forward the caller's options unchanged: the DOM dedupes correctly and Node's
EventEmitter still counts duplicate registrations (removable one-per-call). This
also subsumes the earlier per-event wrapper stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mydea mydea force-pushed the feat/bind-scope-to-emitter branch from 03ee2c5 to 50d7613 Compare June 19, 2026 08:02

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 50d7613. Configure here.

Comment thread packages/core/src/tracing/bindScopeToEmitter.ts Outdated
Comment thread packages/core/src/tracing/bindScopeToEmitter.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants